ποΈGitΠ―ΡΠ°ποΈ
Commit 8ea6e43f8e68e8b10b4e8d767f38f9361696e110
Parents : 97b5b78
Author : James Rich <2199651+jamesarich@users.noreply.github.com>
Signature : Signature validation error
Date : 2026-08-11T20:02:51-07:00
Committer : GitHub <noreply@github.com>
Date : 2026-08-12T03:02:51Z
fix(ui): stop emoji picker clipping text at large font scales (#6631)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Changes
3 files changed, 159 insertions(+), 21 deletions(-)
Diff
diff --git a/.skills/testing-ci/SKILL.md b/.skills/testing-ci/SKILL.md
index 40b2a79187..03778591eb 100644
--- a/.skills/testing-ci/SKILL.md
+++ b/.skills/testing-ci/SKILL.md
@@ -75,7 +75,7 @@ Compose Preview Screenshot Testing (AGP/layoutlib) is split into two modules β
./gradlew :screenshot-tests:copyDocsScreenshots # copy doc images from BOTH modules β docs/assets
```
-Rendering is **host-deterministic** (layoutlib): a local `update` produces references byte-identical to CI, so locally-recorded goldens pass `validate`. `copyDocsScreenshots` overwrites a stale committed `nodes_detail_local.png` each run β `git checkout` it. Public previews consumed cross-module by a wrapper need a `detekt-baseline.xml` entry (PreviewPublic). New screenshot? Pick the module by purpose; see `docs/assets/screenshots/README.md`.
+Rendering is **host-deterministic** (layoutlib): a local `update` produces references byte-identical to CI, so locally-recorded goldens pass `validate`. **Exception β colour emoji: do NOT gate CI on them.** Layoutlib bundles the text fonts but resolves colour emoji through the host's emoji font, so glyph edges rasterise differently on macOS than on the Linux runner. Layout, text and vectors still match exactly; only the emoji anti-aliasing moves, which is enough to blow the 0.0005 `imageDifferenceThreshold` on an emoji-dense composition and cannot be fixed by re-running `update` locally (PR #6631). Assert the layout rule in a unit test instead β see `core/ui/src/commonTest/.../emoji/EmojiCellSizeTest.kt` β or put the composition in generate-only `:docs-screenshots`. `copyDocsScreenshots` overwrites a stale committed `nodes_detail_local.png` each run β `git checkout` it. Public previews consumed cross-module by a wrapper need a `detekt-baseline.xml` entry (PreviewPublic). New screenshot? Pick the module by purpose; see `docs/assets/screenshots/README.md`.
## 3c) Fresh-install manual/agent testing: skip onboarding
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/emoji/EmojiPickerDialog.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/emoji/EmojiPickerDialog.kt
index 756afec952..e1fc29d6bc 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/emoji/EmojiPickerDialog.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/emoji/EmojiPickerDialog.kt
@@ -30,10 +30,13 @@ import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
-import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
@@ -70,10 +73,14 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewLightDark
+import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.max
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Popup
import kotlinx.coroutines.delay
@@ -103,6 +110,12 @@ import org.meshtastic.core.ui.theme.AppTheme
private val GRID_MIN_CELL_SIZE = 44.dp
private const val EMOJI_FONT_SIZE = 24
+private val CELL_CONTENT_PADDING = 12.dp
+private val SKIN_TONE_CELL_MIN_SIZE = 44.dp
+private const val SKIN_TONE_FONT_SIZE = 22
+
+/** Wide enough for all six [SkinTone] variants at [SKIN_TONE_CELL_MIN_SIZE]; wider scales wrap instead. */
+private val SKIN_TONE_POPUP_MAX_WIDTH = 300.dp
private const val CATEGORY_HEADER_KEY_PREFIX = "header_"
private const val RECENTS_HEADER_KEY = "header_recents"
private const val RECENTS_KEY_PREFIX = "recent_"
@@ -273,7 +286,7 @@ private fun SearchBar(query: String, onQueryChange: (String) -> Unit) {
TextField(
value = query,
onValueChange = onQueryChange,
- modifier = Modifier.fillMaxWidth().height(52.dp),
+ modifier = Modifier.fillMaxWidth().heightIn(min = 52.dp),
placeholder = {
Text(
text = stringResource(Res.string.search_emoji),
@@ -421,9 +434,10 @@ private fun EmojiGrid(
}
}
+ val cellSize = emojiCellSize()
LazyVerticalGrid(
state = gridState,
- columns = GridCells.Adaptive(minSize = GRID_MIN_CELL_SIZE),
+ columns = GridCells.Adaptive(minSize = cellSize),
contentPadding = PaddingValues(horizontal = 4.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
@@ -439,6 +453,7 @@ private fun EmojiGrid(
item(key = item.key) {
EmojiCellWithSkinTone(
emoji = item.emoji,
+ cellSize = cellSize,
isSelected = selectedEmojis.contains(item.emoji.base),
preferredSkinToneIndex = preferredSkinToneIndex,
onSkinToneSelect = onSkinToneSelect,
@@ -563,6 +578,16 @@ private fun localizedHeaderTitle(header: GridItem.Header): String = if (header.k
}
}
+/** Grid cell size that grows with the system font scale so emoji glyphs are never clipped. */
+@Composable
+private fun emojiCellSize(): Dp = emojiCellSizeFor(glyphSize = with(LocalDensity.current) { EMOJI_FONT_SIZE.sp.toDp() })
+
+/**
+ * Cell size for a glyph that measures [glyphSize] at the current font scale: never smaller than the glyph plus its
+ * padding, and never below [GRID_MIN_CELL_SIZE] so the cell stays a valid touch target.
+ */
+internal fun emojiCellSizeFor(glyphSize: Dp): Dp = max(GRID_MIN_CELL_SIZE, glyphSize + CELL_CONTENT_PADDING)
+
@Composable
private fun SectionHeader(title: String) {
Text(
@@ -584,6 +609,7 @@ private fun SectionHeader(title: String) {
@Suppress("LongParameterList")
private fun EmojiCellWithSkinTone(
emoji: Emoji,
+ cellSize: Dp,
isSelected: Boolean,
preferredSkinToneIndex: Int,
onSkinToneSelect: (Int) -> Unit,
@@ -596,7 +622,7 @@ private fun EmojiCellWithSkinTone(
Box {
Box(
modifier =
- Modifier.size(GRID_MIN_CELL_SIZE)
+ Modifier.size(cellSize)
.clip(RoundedCornerShape(8.dp))
.then(
if (isSelected) {
@@ -648,23 +674,37 @@ private fun EmojiCellWithSkinTone(
@Composable
private fun SkinTonePopup(emoji: Emoji, onSelect: (String, Int) -> Unit, onDismiss: () -> Unit) {
Popup(alignment = Alignment.TopCenter, onDismissRequest = onDismiss) {
- Surface(
- shape = RoundedCornerShape(12.dp),
- color = MaterialTheme.colorScheme.surfaceContainer,
- shadowElevation = 8.dp,
- border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)),
- modifier = Modifier.widthIn(max = 280.dp),
+ SkinToneSurface(emoji = emoji, onSelect = onSelect)
+ }
+}
+
+/** Split out of [SkinTonePopup] so the scale-sensitive layout is reachable from a preview; [Popup] is not. */
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+private fun SkinToneSurface(emoji: Emoji, onSelect: (String, Int) -> Unit) {
+ Surface(
+ shape = RoundedCornerShape(12.dp),
+ color = MaterialTheme.colorScheme.surfaceContainer,
+ shadowElevation = 8.dp,
+ border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)),
+ modifier = Modifier.widthIn(max = SKIN_TONE_POPUP_MAX_WIDTH),
+ ) {
+ // FlowRow so scale-grown cells wrap to a second line instead of clipping
+ FlowRow(
+ modifier = Modifier.padding(6.dp),
+ horizontalArrangement = Arrangement.spacedBy(2.dp),
+ verticalArrangement = Arrangement.spacedBy(2.dp),
) {
- Row(modifier = Modifier.padding(6.dp), horizontalArrangement = Arrangement.spacedBy(2.dp)) {
- SkinTone.entries.forEachIndexed { index, tone ->
- val variant = emoji.withSkinTone(tone)
- Box(
- modifier =
- Modifier.size(40.dp).clip(RoundedCornerShape(8.dp)).clickable { onSelect(variant, index) },
- contentAlignment = Alignment.Center,
- ) {
- Text(text = variant, fontSize = 22.sp)
- }
+ SkinTone.entries.forEachIndexed { index, tone ->
+ val variant = emoji.withSkinTone(tone)
+ Box(
+ modifier =
+ Modifier.defaultMinSize(minWidth = SKIN_TONE_CELL_MIN_SIZE, minHeight = SKIN_TONE_CELL_MIN_SIZE)
+ .clip(RoundedCornerShape(8.dp))
+ .clickable { onSelect(variant, index) },
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(text = variant, fontSize = SKIN_TONE_FONT_SIZE.sp)
}
}
}
@@ -773,6 +813,47 @@ fun EmojiPickerContentPreview() {
}
}
+/** A skin-tone-capable emoji, so the preview renders all six variants. */
+private val SKIN_TONE_PREVIEW_EMOJI = Emoji("π", listOf("wave", "hand", "hello"), supportsSkinTone = true)
+
+@Suppress("UnusedPrivateMember", "PreviewPublic")
+@Preview(fontScale = 2.0f)
+@Composable
+fun EmojiPickerContentLargeFontPreview() {
+ AppTheme {
+ Surface {
+ EmojiPickerContent(
+ searchQuery = "",
+ debouncedQuery = "",
+ onSearchQueryChange = {},
+ selectedCategoryIndex = 0,
+ onCategorySelected = {},
+ selectedEmojis = setOf("π", "π"),
+ recentEmojis = listOf("π", "β€οΈ", "π", "π₯", "π", "π"),
+ categories = PREVIEW_CATEGORIES,
+ allEmojis = PREVIEW_CATEGORIES.flatMap { it.emojis },
+ preferredSkinToneIndex = 0,
+ onSkinToneSelect = {},
+ onEmojiSelected = {},
+ )
+ }
+ }
+}
+
+@Suppress("UnusedPrivateMember", "PreviewPublic")
+@PreviewLightDark
+@Composable
+fun SkinTonePopupPreview() {
+ AppTheme { Surface { SkinToneSurface(emoji = SKIN_TONE_PREVIEW_EMOJI, onSelect = { _, _ -> }) } }
+}
+
+@Suppress("UnusedPrivateMember", "PreviewPublic")
+@Preview(fontScale = 2.0f)
+@Composable
+fun SkinTonePopupLargeFontPreview() {
+ AppTheme { Surface { SkinToneSurface(emoji = SKIN_TONE_PREVIEW_EMOJI, onSelect = { _, _ -> }) } }
+}
+
@Suppress("UnusedPrivateMember", "PreviewPublic")
@PreviewLightDark
@Composable
diff --git a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/emoji/EmojiCellSizeTest.kt b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/emoji/EmojiCellSizeTest.kt
new file mode 100644
index 0000000000..e8e54a22d6
--- /dev/null
+++ b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/emoji/EmojiCellSizeTest.kt
@@ -0,0 +1,57 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.ui.emoji
+
+import androidx.compose.ui.unit.dp
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+/**
+ * Guards the emoji-picker grid against clipping its glyphs at large system font scales (meshtastic/design#86).
+ *
+ * The picker's own rendering is not asserted here: color emoji glyphs come from the host's emoji font, so a screenshot
+ * golden of them is not reproducible between a dev machine and CI. The sizing rule is, so that is what is pinned.
+ */
+class EmojiCellSizeTest {
+
+ /** 24.sp emoji at the font scales Android exposes, in dp. */
+ private val glyphAtScale = mapOf(1.0f to 24.dp, 1.15f to 27.6.dp, 1.3f to 31.2.dp, 1.5f to 36.dp, 2.0f to 48.dp)
+
+ @Test
+ fun `cell always fits the glyph`() {
+ glyphAtScale.forEach { (scale, glyph) ->
+ val cell = emojiCellSizeFor(glyph)
+ assertTrue(cell >= glyph, "at fontScale $scale a $glyph glyph would be clipped by a $cell cell")
+ }
+ }
+
+ @Test
+ fun `cell never drops below the minimum touch target`() {
+ glyphAtScale.values.forEach { glyph -> assertTrue(emojiCellSizeFor(glyph) >= 44.dp) }
+ }
+
+ @Test
+ fun `default font scale keeps the original cell size`() {
+ assertEquals(44.dp, emojiCellSizeFor(24.dp))
+ }
+
+ @Test
+ fun `cell grows once the glyph outgrows the minimum`() {
+ assertTrue(emojiCellSizeFor(48.dp) > emojiCellSizeFor(24.dp))
+ }
+}
Served by rngit 1.5.4 - Generated in 0.05s